Inflation indexes and curves

import QuantLib as ql
import pandas as pd
today = ql.Date(11, ql.May, 2024)
ql.Settings.instance().evaluationDate = today

Inflation indexes

The library provides classes to model some predefined inflation indexes, such as EUHICP or UKRPI; any missing one can be defined using the base class ZeroInflationIndex.

hicp = ql.EUHICP()

Historical fixings can be saved (for all instances of the same index) by calling the addFixing method.

The method is inherited from the base Index class, so it requires a specific date even if an inflation fixing corresponds to a whole month. By convention, the date we’ll pass together with a fixing must be the first day of the corresponding month (not the day of publishing, which would be in the following month).

inflation_fixings = [
    ((2022, ql.January), 110.70),
    ((2022, ql.February), 111.74),
    ((2022, ql.March), 114.46),
    ((2022, ql.April), 115.11),
    ((2022, ql.May), 116.07),
    ((2022, ql.June), 117.01),
    ((2022, ql.July), 117.14),
    ((2022, ql.August), 117.85),
    ((2022, ql.September), 119.26),
    ((2022, ql.October), 121.03),
    ((2022, ql.November), 120.95),
    ((2022, ql.December), 120.52),
    ((2023, ql.January), 120.27),
    ((2023, ql.February), 121.24),
    ((2023, ql.March), 122.34),
    ((2023, ql.April), 123.12),
    ((2023, ql.May), 123.15),
    ((2023, ql.June), 123.47),
    ((2023, ql.July), 123.36),
    ((2023, ql.August), 124.03),
    ((2023, ql.September), 124.43),
    ((2023, ql.October), 124.54),
    ((2023, ql.November), 123.85),
    ((2023, ql.December), 124.05),
    ((2024, ql.January), 123.60),
    ((2024, ql.February), 124.37),
    ((2024, ql.March), 125.31),
    ((2024, ql.April), 126.05),
]

for (year, month), fixing in inflation_fixings:
    hicp.addFixing(ql.Date(1, month, year), fixing)

Asking for a fixing for any past date will return the fixing for the month:

hicp.fixing(ql.Date(15, ql.March, 2024))
125.31

Of course, some past dates still don’t have an inflation fixing available: at the time of this writing, in the middle of May 2024, the index for this month is not published yet. Fixings for these dates, as well as for future dates, will need to be forecast: and for that, we require an inflation term structure.

Another note: in the past, the index could also return interpolated fixings; however, the result was not always right, since the index didn’t have the correct information on the number of days to use while interpolating. In version 1.29, the interpolation logic was moved into inflation coupons, where the information is available; the corresponding logic in the index was deprecated in the same release and removed in version 1.34.

The logic behind the interpolation is also available as a static method in the CPI class, used by inflation coupons:

observation_lag = ql.Period(3, ql.Months)

ql.CPI.laggedFixing(
    hicp, ql.Date(15, ql.May, 2024), observation_lag, ql.CPI.Linear
)
124.79451612903226

For instance, the call above interpolates linearly a fixing for May 15th, 2024 with an observation lag of three months. This means that the fixings to be interpolated will be those for February and March 2024, but their weights in the interpolation will be based on the number of days between May 1st and May 15th and between May 15th and June 1st.

Inflation curves

As for other kinds of term structures, it’s possible to create inflation curves by bootstrapping over a number of quoted instruments; at this time, the library provides helpers for zero-coupon inflation swaps. The information needed to build them includes, besides more common data such as the calendar and day count convention, an observation lag for the fixing of the underlying inflation index and the interpolation to be used between fixings.

The helpers will also need an external nominal curve of interest rates, used internally for discounting; however, since it applies the same discount factor to the single cash flow of each leg, it has no actual effect on the calculation of the fair rate and might be made unnecessary in future releases.

inflation_quotes = [
    (ql.Period(1, ql.Years), 2.93),
    (ql.Period(2, ql.Years), 2.95),
    (ql.Period(3, ql.Years), 2.965),
    (ql.Period(4, ql.Years), 2.98),
    (ql.Period(5, ql.Years), 3.0),
    (ql.Period(7, ql.Years), 3.06),
    (ql.Period(10, ql.Years), 3.175),
    (ql.Period(12, ql.Years), 3.243),
    (ql.Period(15, ql.Years), 3.293),
    (ql.Period(20, ql.Years), 3.338),
    (ql.Period(25, ql.Years), 3.348),
    (ql.Period(30, ql.Years), 3.348),
    (ql.Period(40, ql.Years), 3.308),
    (ql.Period(50, ql.Years), 3.228),
]

calendar = ql.TARGET()
observation_lag = ql.Period(3, ql.Months)
day_counter = ql.Thirty360(ql.Thirty360.BondBasis)
interpolation = ql.CPI.Linear

nominal_curve = ql.YieldTermStructureHandle(
    ql.FlatForward(today, 0.03, ql.Actual365Fixed())
)

helpers = []

for tenor, quote in inflation_quotes:
    maturity = calendar.advance(today, tenor)
    helpers.append(
        ql.ZeroCouponInflationSwapHelper(
            ql.makeQuoteHandle(quote / 100),
            observation_lag,
            maturity,
            calendar,
            ql.Following,
            day_counter,
            hicp,
            interpolation,
            nominal_curve,
        )
    )

Now, inflation curves are kind of odd compared to other curves. Usually, term structures in QuantLib (e.g. for interest rates or default probabilities) forecast their underlying quantities starting from today; yesterday’s rates are assumed to be known, and so is whether an issuer defaulted. As I mentioned, though, inflation curves can also be used to forecast still unpublished fixings corresponding to past dates. Therefore, they have a so-called base date in the past which separates known and unknown fixings.

Up to version 1.33, though, the base date was not specified explicitly. Instead, the constructors of most inflation curves required an observation lag, probably because of some past confusion with the observation lag of the instruments used for bootstrapping; the base date was calculated by starting from today’s date, subtracting the lag and taking the first date of the resulting month.

However, the only base date that makes sense is the date of the last available inflation fixing: the fixings are known up to that point and need to be forecast after it. Therefore, the observation lag could not be a constant attribute of a given curve, but had to be calculated based on the available data: at the beginning of May, when the April fixing was not published yet, we needed a lag of two months to get to March, while mid-May we had to switch to a lag of one month to get an April base date as soon as the corresponding fixing was published. The curve also needed a base rate to be used for \(t=0\); in the current implementation, the base rate is still required when using the old constructor but is ignored by the bootstrapping process.

Starting with version 1.34, it’s possible to specify the base date explicitly; in order to determine it, we also added a lastFixingDate method to inflation indexes for convenience.

hicp.lastFixingDate()
Date(1,4,2024)
fixing_frequency = ql.Monthly

inflation_curve = ql.PiecewiseZeroInflation(
    today,
    hicp.lastFixingDate(),
    fixing_frequency,
    ql.Actual365Fixed(),
    helpers,
)
pd.DataFrame(
    inflation_curve.nodes(), columns=["node", "rate"]
).style.format({"rate": "{:.4%}"})
  node rate
0 April 1st, 2024 2.0985%
1 March 1st, 2025 2.0985%
2 March 1st, 2026 2.5889%
3 March 1st, 2027 2.7210%
4 March 1st, 2028 2.7980%
5 March 1st, 2029 2.8545%
6 March 1st, 2031 2.9586%
7 March 1st, 2034 3.1057%
8 March 1st, 2036 3.1858%
9 March 1st, 2039 3.2467%
10 March 1st, 2044 3.3029%
11 March 1st, 2049 3.3190%
12 March 1st, 2054 3.3235%
13 March 1st, 2064 3.2888%
14 March 1st, 2074 3.2117%

Using the curve, an inflation index can forecast future published fixings:

inflation_handle = ql.RelinkableZeroInflationTermStructureHandle(
    inflation_curve
)
hicp = ql.EUHICP(inflation_handle)
hicp.fixing(ql.Date(1, ql.April, 2027))
136.6479256339906

The same holds for interpolated fixings:

ql.CPI.laggedFixing(
    hicp, ql.Date(15, ql.May, 2027), observation_lag, ql.CPI.Linear
)
136.13606698424962

In case you’re stuck to versions earlier than 1.34, you’ll have to use the old constructor instead; the corresponding code would be something like

availability_lag = ql.Period(1, ql.Months)
fixing_frequency = ql.Monthly
base_rate = 0.029

inflation_curve = ql.PiecewiseZeroInflation(
    today,
    calendar,
    ql.Actual365Fixed(),
    availability_lag,
    fixing_frequency,
    base_rate,
    helpers,
)

The old constructors for the various inflation curves available in the library are currently deprecated and will be removed in QuantLib 1.39.

Adding seasonality

It’s possible to add seasonality to the inflation curve; here, we’ll use multiplicative factors. They have to be calculated externally, as the library doesn’t currently provide the means to do so. In this case, I’ll assume they go from January to December; but they might also go, for instance, from April to March depending on how you calculate them.

seasonalityFactors = [
    1.003245,
    1.001994,
    0.999715,
    1.000495,
    1.000929,
    0.998687,
    0.995949,
    0.994682,
    0.995949,
    1.000519,
    1.003705,
    1.004186,
]

Since the factors might start from any month, the constructor of the object modeling the seasonality also needs to be passed a seasonality base date specifying the month corresponding to the first factor; in this case, January.

seasonality = ql.MultiplicativePriceSeasonality(
    ql.Date(1, ql.January, 2025), ql.Monthly, seasonalityFactors
)

If we apply the seasonality to the curve…

inflation_curve.setSeasonality(seasonality)

…the curve will re-bootstrap to take it into account, and future fixings will change:

hicp.fixing(ql.Date(1, ql.April, 2027))
136.54385988110138
ql.CPI.laggedFixing(
    hicp, ql.Date(15, ql.May, 2027), observation_lag, ql.CPI.Linear
)
136.09620479633497

Now, since the base date of the curve is April and since we have a known fixing at that point, seasonality (which cancels out over the course of a whole year) shouldn’t affect April fixings. Therefore, it might be puzzling (it was for me at first) that the April 2027 fixing calculated here is different from the one forecast earlier, after we first created the curve without seasonality.

The reason is that, as I mentioned, the current curve was re-bootstrapped taking seasonality into account and has no longer the same nodes; therefore, we’re not comparing the same curve with or without seasonality. If we want to do that as a check, we can extract the nodes and create an equivalent curve that doesn’t re-bootstrap. We can do that without seasonality first…

nodes = inflation_curve.nodes()
nodes
((Date(1,4,2024), 0.020172934548877586),
 (Date(1,3,2025), 0.020172934548877586),
 (Date(1,3,2026), 0.025490853073074065),
 (Date(1,3,2027), 0.026943228657632365),
 (Date(1,3,2028), 0.02778096487307639),
 (Date(1,3,2029), 0.02838580813489084),
 (Date(1,3,2031), 0.02948449311816098),
 (Date(1,3,2034), 0.0309775927607149),
 (Date(1,3,2036), 0.031799059657994795),
 (Date(1,3,2039), 0.03241456296581881),
 (Date(1,3,2044), 0.03298906414979337),
 (Date(1,3,2049), 0.03315870952594375),
 (Date(1,3,2054), 0.03320842203753793),
 (Date(1,3,2064), 0.03287005733130273),
 (Date(1,3,2074), 0.03210140433374672))
dates, rates = zip(*nodes)
cloned_curve = ql.ZeroInflationCurve(
    today,
    dates,
    rates,
    inflation_curve.frequency(),
    inflation_curve.dayCounter(),
)
inflation_handle.linkTo(cloned_curve)

…and retrieve the fixings for a few months, including April.

f1 = hicp.fixing(ql.Date(1, ql.March, 2027))
f2 = hicp.fixing(ql.Date(1, ql.April, 2027))
f3 = hicp.fixing(ql.Date(1, ql.May, 2027))
print(f1, f2, f3)
136.2076502192149 136.54385988110138 136.8715417390868

Now we can add the seasonality. In this case, the curve nodes will remain the same and we can make a meaningful comparison:

cloned_curve.setSeasonality(seasonality)
f1_s = hicp.fixing(ql.Date(1, ql.March, 2027))
f2_s = hicp.fixing(ql.Date(1, ql.April, 2027))
f3_s = hicp.fixing(ql.Date(1, ql.May, 2027))
print(f1_s, f2_s, f3_s)
136.1014608157986 136.54385988110138 136.9309145986361

We can see how the fixings for March and May changed, but the one for April didn’t—as expected.

As a further check, we can also calculate the factor that was applied to the May fixing…

f3_s / f3
1.0004337852762881

…and recalculate it from the passed seasonality factors. Since April remains the same, the other factors are scaled with respect to it.

seasonalityFactors[4] / seasonalityFactors[3]
1.0004337852762883